# 实战篇 05:菜单匹配逻辑

本节参考代码:
react-sider

在大部分企业管理系统中,页面的基础布局所采取的一般都是侧边栏菜单加页面内容这样的组织形式。在成熟的组件库支持下,UI 层面想要做出一个漂亮的侧边栏菜单并不困难,但因为在企业管理系统中菜单还承担着页面导航的功能,于是就导致了两大难题,一是多级菜单如何处理,二是菜单项的子页面(如点击门店管理中的某一个门店进入的门店详情页在菜单中并没有对应的菜单项)如何高亮其隶属于的父级菜单。

# 多级菜单

为了增强系统的可扩展性,企业管理系统中的菜单一般都需要提供多级支持,对应的数据结构就是在每一个菜单项中都要有 children 属性来配置下一级菜单项。

const menuData = [{
  name: '仪表盘',
  icon: 'dashboard',
  path: 'dashboard',
  children: [{
    name: '分析页',
    path: 'analysis',
    children: [{
      name: '实时数据',
      path: 'realtime',
    }, {
      name: '离线数据',
      path: 'offline',
    }],
  }],
}];

# 递归渲染父菜单及子菜单

想要支持多级菜单,首先要解决的问题就是如何统一不同级别菜单项的交互。

在大多数的情况下,每一个菜单项都代表着一个不同的页面路径,点击后会触发 url 的变化并跳转至相应页面,也就是上面配置中的 path 字段。

但对于一个父菜单来说,点击还意味着打开或关闭相应的子菜单,这就与点击跳转页面发生了冲突。为了简化这个问题,我们先统一菜单的交互为点击父菜单(包含 children 属性的菜单项)为打开或关闭子菜单,点击子菜单(不包含 children 属性的菜单项)为跳转至相应页面。

首先,为了成功地渲染多级菜单,菜单的渲染函数是需要支持递归的,即如果当前菜单项含有 children 属性就将其渲染为父菜单并优先渲染其 children 字段下的子菜单,这在算法上被叫做深度优先遍历

renderMenu = data => (
  map(data, (item) => {
    if (item.children) {
      return (
        <SubMenu
          key={item.path}
          title={
            <span>
              <Icon type={item.icon} />
              <span>{item.name}</span>
            </span>
          }
        >
          {this.renderMenu(item.children)}
        </SubMenu>
      );
    }

    return (
      <Menu.Item key={item.path}>
        <Link to={item.path} href={item.path}>
          <Icon type={item.icon} />
          <span>{item.name}</span>
        </Link>
      </Menu.Item>
    );
  })
)

这样我们就拥有了一个支持多级展开、子菜单分别对应页面路由的侧边栏菜单。细心的朋友可能还发现了,虽然父菜单并不对应一个具体的路由但在配置项中依然还有 path 这个属性,这是为什么呢?

# 处理菜单高亮

在传统的企业管理系统中,为不同的页面配置页面路径是一件非常痛苦的事情,对于页面路径,许多开发者唯一的要求就是不重复即可,如上面的例子中,我们把菜单数据配置成这样也是可以的。

const menuData = [{
  name: '仪表盘',
  icon: 'dashboard',
  children: [{
    name: '分析页',
    children: [{
      name: '实时数据',
      path: '/realtime',
    }, {
      name: '离线数据',
      path: '/offline',
    }],
  }],
}];

<Router>
  <Route path="/realtime" render={() => <div />}
  <Route path="/offline" render={() => <div />}
</Router>

用户在点击菜单项时一样可以正确地跳转到相应页面。但这样做的一个致命缺陷就是,对于 /realtime 这样一个路由,如果只根据当前的 pathname 去匹配菜单项中 path 属性的话,要怎样才能同时也匹配到「分析页」与「仪表盘」呢?因为如果匹配不到的话,「分析页」和「仪表盘」就不会被高亮了。我们能不能在页面的路径中直接体现出菜单项之间的继承关系呢?来看下面这个工具函数。

import map from 'lodash/map';

const formatMenuPath = (data, parentPath = '/') => (
  map(data, (item) => {
    const result = {
      ...item,
      path: `${parentPath}${item.path}`,
    };
    if (item.children) {
      result.children = formatMenuPath(item.children, `${parentPath}${item.path}/`);
    }
    return result;
  })
);

这个工具函数把菜单项中可能有的 children 字段考虑了进去,将一开始的菜单数据传入就可以得到如下完整的菜单数据。

[{
  name: '仪表盘',
  icon: 'dashboard',
  path: '/dashboard',  // before is 'dashboard'
  children: [{
    name: '分析页',
    path: '/dashboard/analysis', // before is 'analysis'
    children: [{
      name: '实时数据',
      path: '/dashboard/analysis/realtime', // before is 'realtime'
    }, {
      name: '离线数据',
      path: '/dashboard/analysis/offline', // before is 'offline'
    }],
  }],
}];

然后让我们再对当前页面的路由做一下逆向推导,即假设当前页面的路由为 /dashboard/analysis/realtime,我们希望可以同时匹配到 ['/dashboard', '/dashboard/analysis', '/dashboard/analysis/realtime'],方法如下:

import map from 'lodash/map';

const urlToList = (url) => {
  if (url) {
    const urlList = url.split('/').filter(i => i);
    return map(urlList, (urlItem, index) => `/${urlList.slice(0, index + 1).join('/')}`);
  }
  return [];
};

上面的这个数组代表着不同级别的菜单项,将这三个值分别与菜单数据中的 path 属性进行匹配就可以一次性地匹配到所有当前页面应当被高亮的菜单项了。

这里需要注意的是,虽然菜单项中的 path 一般都是普通字符串,但有些特殊的路由也可能是正则的形式,如 /outlets/:id。所以我们在对二者进行匹配时,还需要引入 path-to-regexp 这个库来处理类似 /outlets/1/outlets/:id 这样的路径。又因为初始时菜单数据是树形结构的,不利于进行 path 属性的匹配,所以我们还需要先将树形结构的菜单数据扁平化,然后再传入 getMeunMatchKeys 中。

import pathToRegexp from 'path-to-regexp';
import reduce from 'lodash/reduce';
import filter from 'lodash/filter';

const getFlatMenuKeys = menuData => (
  reduce(menuData, (keys, item) => {
    keys.push(item.path);
    if (item.children) {
      return keys.concat(getFlatMenuKeys(item.children));
    }
    return keys;
  }, [])
);

const getMeunMatchKeys = (flatMenuKeys, paths) =>
  reduce(paths, (matchKeys, path) => (
    matchKeys.concat(filter(flatMenuKeys, item => pathToRegexp(item).test(path)))
  ), []);

在这些工具函数的帮助下,多级菜单的高亮也不再是问题了。

# 知识点:记忆化(Memoization)

在侧边栏菜单中,有两个重要的状态:一个是 selectedKeys,即当前选定的菜单项;另一个是 openKeys,即多个多级菜单的打开状态。这二者的含义是不同的,因为在 selectedKeys 不变的情况下,用户在打开或关闭其他多级菜单后,openKeys 是会发生变化的,如下面二图所示,selectedKeys 相同但 openKeys 不同。

对于 selectedKeys 来说,由于它是由页面路径(pathname)决定的,所以每一次 pathname 发生变化都需要重新计算 selectedKeys 的值。又因为通过 pathname 以及最基础的菜单数据 menuData 去计算 selectedKeys 是一件非常昂贵的事情(要做许多数据格式处理和计算),有没有什么办法可以优化一下这个过程呢?

Memoization 可以赋予普通函数记忆输出结果的功能,它会在每次调用函数之前检查传入的参数是否与之前执行过的参数完全相同,如果完全相同则直接返回上次计算过的结果,就像常用的缓存一样。

import memoize from 'memoize-one';

constructor(props) {
  super(props);

  this.fullPathMenuData = memoize(menuData => formatMenuPath(menuData));
  this.selectedKeys = memoize((pathname, fullPathMenu) => (
    getMeunMatchKeys(getFlatMenuKeys(fullPathMenu), urlToList(pathname))
  ));

  const { pathname, menuData } = props;

  this.state = {
    openKeys: this.selectedKeys(pathname, this.fullPathMenuData(menuData)),
  };
}

在组件的构造器中我们可以根据当前 props 传来的 pathnamemenuData 计算出当前的 selectedKeys 并将其当做 openKeys 的初始值初始化组件内部 state。因为 openKeys 是由用户所控制的,所以对于后续 openKeys 值的更新我们只需要配置相应的回调将其交给 Menu 组件控制即可。

import Menu from 'antd/lib/menu';

handleOpenChange = (openKeys) => {
  this.setState({
    openKeys,
  });
};

<Menu
  style={{ padding: '16px 0', width: '100%' }}
  mode="inline"
  theme="dark"
  openKeys={openKeys}
  selectedKeys={this.selectedKeys(pathname, this.fullPathMenuData(menuData))}
  onOpenChange={this.handleOpenChange}
>
  {this.renderMenu(this.fullPathMenuData(menuData))}
</Menu>

这样我们就实现了对于 selectedKeysopenKeys 的分别管理,开发者在使用侧边栏组件时只需要将应用当前的页面路径同步到侧边栏组件中的 pathname 属性即可,侧边栏组件会自动处理相应的菜单高亮(selectedKeys)和多级菜单的打开与关闭(openKeys)。

# 知识点:正确区分 prop 与 state

上述这个场景也是一个非常经典的关于如何正确区分 prop 与 state 的例子。

selectedKeys 由传入的 pathname 决定,于是我们就可以将 selectedKeyspathname 之间的转换关系封装在组件中,使用者只需要传入正确的 pathname 就可以获得相应的 selectedKeys 而不需要关心它们之间的转换是如何完成的。而 pathname 作为组件渲染所需的基础数据,组件无法从自身内部获得,所以就需要使用者通过 props 将其传入进来。

另一方面, openKeys 作为组件内部的 state,初始值可以由 pathname 计算而来,后续的更新则与组件外部的数据无关而是会根据用户的操作在组件内部完成,那么它就是一个 state,与其相关的所有逻辑都可以彻底地被封装在组件内部而不需要暴露给使用者。

简而言之,一个数据如果想成为 prop 就必须是组件内部无法获得的,而且在它成为了 prop 之后,所有可以根据它的值推导出来的数据都不再需要成为另外的 props,否则将违背 React 单一数据源的原则。对于 state 来说也是同样,如果一个数据想成为 state,那么它就不应该再能够被组件外部的值所改变,否则也会违背单一数据源的原则而导致组件的表现不可预测,产生难解的 bug。

# 组合式开发:应用菜单

严格来说,在这一小节中着重探讨的应用菜单部分的思路并不属于组合式开发思想的范畴,更多地是如何写出一个支持无限级子菜单及自动匹配当前路由的菜单组件。组件当然是可以随意插拔的,但前提是应用该组件的父级部分不依赖于组件所提供的信息。这也是我们在编写组件时所应当遵循的一个规范,即组件可以从外界获取信息并在此基础上进行组件内部的逻辑判断。但当组件向其外界抛出信息时,更多的时候应该是以回调的形式让调用者去主动触发,然后更新外部的数据再以 props 的形式传递给组件以达到更新组件的目的,而不是强制需要在外部再配置一个回调的接收函数去直接改变组件的内部状态。

从这点上来说,组合式开发与组件封装其实是有着异曲同工之妙的,关键都在于对内部状态的严格控制。不论一个模块或一个组件需要向外暴露多少接口,在它的内部都应该是解决了某一个或某几个具体问题的。就像工厂产品生产流水线上的一个环节,在经过了这一环节后产品相较于进入前一定产生了某种区别,不论是增加了某些功能还是被打上某些标签,产品一定会变得更利于下游合作者使用。更理想的情况则是即使删除掉了这一环节,原来这一环节的上下游依然可以无缝地衔接在一起继续工作,这就是我们所说的模块或者说组件的可插拔性。

# 小结

在本节中我们讨论了配置式菜单的数据结构设计以及基于这样的数据结构,如何做到在页面路径中体现页面与页面之间的父子级关系并在侧边栏菜单中高亮其对应的菜单项,还引出了「记忆化」的概念,为各位在处理组件中的复杂计算提供了一种优化方案以及如何正确地区分组件的 props 与 state,如何在不牺牲组件功能的前提下降低组件使用时的复杂度并提升组件的稳定性。

在下一节中我们将会探讨如何处理企业管理系统中的系统通知以及全局级别的用户操作反馈。

如果你想参与到文章中内容的讨论,欢迎在下面的评论区留言,期待与大家的交流。

阅读全文